// This work is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)
// https://creativecommons.org/licenses/by-nc-sa/4.0/
// © AlgoNorth

//@version=6
indicator("Delta Pressure Clock [AlgoNorth]", "D.P. Clock", overlay = true, max_polylines_count = 100, max_labels_count = 50, max_boxes_count = 100, behind_chart = false)

// Who has owned each hour of the last 24 hours, read off a 24-hour clock: one wedge per hour (green = net
// estimated buying, red = net selling, longer = more), the dashed line is each hour's usual size, the hand is
// the hour of the last bar and the dotted arcs are the Asia, London and New York sessions. Two moving-average
// lines through price show the trend, each tagged with its EMA length, the panel's three tiles read three delta
// windows, and the candles can be coloured by the side of a chosen line they close on.
// All delta here is an ESTIMATE made from the direction of lower-timeframe price moves. It is not exchange
// bid/ask or order-flow data.

//------------------------------------------------------------------------------
// Inputs
//------------------------------------------------------------------------------
string G_TF    = "Delta windows (panel tiles)"
string G_LINE  = "Lines"
string G_DELTA = "Delta estimate"
string G_CLOCK = "Clock and panel"
string G_LOOK  = "Look"

string TT_TF   = "Three windows to measure estimated delta over, shortest first: the panel's three tiles. The two lines have timeframes of their own, under Lines.\n\nDelta Pressure Clock works on intraday charts of 1 hour or less, on symbols that report volume and on standard candle charts.\n\nA window shorter than the chart bar is raised to the chart bar, and two windows that come out the same whole number of chart bars are nudged one bar apart. Either way the tiles are renamed to the window actually measured, so what you read is what was summed.\n\nThe three windows are NESTED - each is a trailing sum of the same series, the longer ones containing the shorter - so they agree in sign about half the time even on a directionless tape. Treat three tiles that agree as a description of the three readings, not as evidence; it is disagreement between them that carries information.\n\nEach window's delta is then smoothed so the reading does not flicker, and the smoothing grows with the window: at the 5-minute defaults the three smoothings are 7, 14 and 28 bars, or about 35, 70 and 140 minutes. That is why the 4h reading reacts over about six hours and lags roughly three, and still carries a little weight on flow about seven hours old.\n\nThe windows are counted in bars, not in clock time, so one that spans a session break reaches back across it."

string tfFastIn   = input.timeframe("15", "Fast", group = G_TF, inline = "tf", tooltip = TT_TF)
string tfMidIn    = input.timeframe("60", "Mid", group = G_TF, inline = "tf")
string tfSlowIn   = input.timeframe("240", "Slow", group = G_TF, inline = "tf")
string lnFastIn   = input.timeframe("15", "Fast line", group = G_LINE, inline = "lntf", tooltip = "The timeframe of each line. A line is an average of price over that timeframe. Set the fast line shorter than the slow one. A timeframe shorter than the chart bar is raised to the chart bar, and two that come out the same whole number of chart bars are nudged one bar apart; the tags show the EMA length actually used.")
string lnSlowIn   = input.timeframe("60", "Slow line", group = G_LINE, inline = "lntf")
float  lineMult   = input.float(4.0, "Line length (x timeframe ratio)", minval = 1, maxval = 20, step = 0.5, group = G_LINE, tooltip = "Each line is an EMA of close whose length is this number times its timeframe's ratio to the chart bar, so the line is always this many of its own timeframe long. On a 5-minute chart the defaults give 12 and 48 bars; on a 15-minute chart they give 4 and 16. No line is ever shorter than 2 bars, and a line that would need more history than TradingView holds is shortened, which the panel says. The resolved lengths are printed on the panel's bottom line.")
string ltfIn      = input.timeframe("", "Lower timeframe (blank = auto)", group = G_DELTA, tooltip = "How delta is estimated. The script splits each chart bar into shorter bars and counts each short bar's whole volume as buying if it closed above the previous short bar's close, selling if it closed below it, and the same way as the bar before it if it closed level - the tick rule, applied to short bars. A move between two short bars therefore counts as well as a move within one, so a market that steps down from bar to bar reads as selling even when each short bar edges up inside itself. The first short bar of a session has no previous close and is measured against its own open; a bar contributes nothing until a direction has been seen, and the count starts again at each session.\n\nBlank picks the coarsest whole minute that divides the chart bar exactly while still giving about five short bars per chart bar - fine enough to mean something, without asking for far more intrabar history than it needs. A bar length with no suitable divisor falls back to 1 minute, so a 7-minute chart is read in seven 1-minute bars and a 2-minute chart in two; on charts of 1 minute and below the chart bar itself is signed. A value you type must be shorter than the chart bar and must divide it evenly, or the script says which value to use instead.\n\nIntrabar data only reaches back a limited distance, so on older bars there may be no delta at all - the three tiles read NO DATA until the whole scale window is full of measured bars. A single bar whose data did not arrive contributes nothing, which sits in the window as a zero.\n\nThis is an estimate from the direction of lower-timeframe price moves. It is not exchange bid/ask or order-flow data.")
int    normLen    = input.int(300, "Delta scale lookback (bars)", minval = 50, maxval = 2000, group = G_DELTA, tooltip = "Each window's smoothed delta is scaled to -1..+1 against the 90th percentile of its SIZE (absolute value) over this many bars. A reading at or beyond that size counts as full strength, so roughly the strongest bar in ten looks the same; smaller readings fade toward flat. The scaled reading sets the tiles.\n\nEach window is scaled only against itself, so compare a window with its own past rather than with the other two. The lookback has to be several times the slowest window to mean anything, so the script stretches it per window when this number is too small for that window's memory.")
bool   showClock  = input.bool(true, "Show the clock", group = G_CLOCK)
bool   showPanel  = input.bool(true, "Show the panel", group = G_CLOCK, tooltip = "The panel can be shown on its own with the clock switched off.")
int    dialR      = input.int(36, "Size - clock and panel together", minval = 24, maxval = 150, group = G_CLOCK, tooltip = "THE setting that resizes the indicator: the clock and the panel grow and shrink together, in proportion. It is the clock's radius in bar columns, so it also sets how many bars the picture takes up - with its ring of hour numbers the whole figure is about 2.4 times this wide (36 takes roughly 90 bars). Larger is a finer circle and finer wedges, because every point of the picture sits on a bar column: zoom the time axis out and raise this to keep the same size on screen with more detail. TradingView does not tell a script how far the chart is zoomed, and a bar is about the same width on screen at the usual zoom on every timeframe, so one Size suits every timeframe; raise it when zoomed out, lower it when zoomed in. Price per bar below does NOT resize it - change that and the picture gets taller or shorter without getting wider.\n\nWedge length is measured against one fixed scale - the size of the strongest tenth of all the finished hours on the chart, measured when the chart loads and then held fixed until it reloads or a setting changes - so a wedge at the rim is a top-tenth hour and a quiet day draws short wedges. Past the shortest wedge, length grows with the square root of the delta, so four times the delta adds twice the length. Length ratios are not delta ratios. So that every wedge's sides lie on their hour lines and its curved end lands cleanly on the bar grid, a wedge's tip may sit up to 3% of the radius off its exact length, and up to 5% where 3% would still leave a corner off its curve. The banner grades the hour in progress on the same scale as its wedge: mild below the strength at which a wedge gets its full-colour core, medium from there, and strong from the strength at which it gets its bright tip.")
float  ppbIn      = input.float(0.0, "Price per bar (0 = auto)", minval = 0, maxval = 1000, step = 0.05, group = G_CLOCK, tooltip = "The clock's radius in price is the size setting times this number. It is not a size control: keep it fixed so the clock stays round. 0 = auto: sized from the recent range when the chart loads, then frozen until the chart reloads or a setting changes. The value in use is printed on the panel's last line (ppb) - type it in here to keep exactly that size for good.\n\nThe clock is drawn on the chart itself, so TradingView stretches it when you zoom one axis more than the other. To keep it round at every zoom: zoom until it looks round, then right-click the price scale and tick Lock price to bar ratio. While the lock is on the price axis no longer fits itself to the chart, so you may have to drag it to bring the clock back into view.")
string posIn      = input.string("At price", "Position", options = ["Above price", "At price", "Below price"], group = G_CLOCK, tooltip = "Where the clock sits against the last close. At price: the centre of the clock is on the last close. Above price: the centre is half the clock's radius above the last close. Below price: half the radius below it. The panel moves with the clock.")
int    clockGap   = input.int(20, "Gap from the last bar", minval = 5, maxval = 200, group = G_CLOCK, tooltip = "Roughly this many bars between the last candle and the left edge of the space the clock reserves, which includes the ring of hour numbers around it. TradingView will not draw anything more than 500 bars past the last candle, so at large radii the gap is trimmed automatically to whatever still fits.")
int    rangeLen   = input.int(200, "Range lookback (bars)", minval = 20, maxval = 2000, group = G_CLOCK, tooltip = "Used by the auto size. On a chart with fewer bars than this, whatever history there is is used instead.")
float  clockShare = input.float(0.3, "Auto size: radius as share of that range", minval = 0.05, maxval = 2, step = 0.05, group = G_CLOCK, tooltip = "Only used when Price per bar is 0. At a size of 48 bars the clock's radius in price is this share of the Range lookback's high-to-low; at other radii it scales with the radius, so the picture keeps its proportions. There is a floor of twenty ticks (also scaled) so a flat range cannot collapse the clock. The size is measured once, when the chart loads, and then frozen until it reloads or a setting changes, so with Lock price to bar ratio ticked the clock never changes shape.")
int    typDays    = input.int(5, "Typical hour - smoothing (days)", minval = 1, maxval = 60, group = G_CLOCK, tooltip = "Each hour's usual size - the dashed line on the clock - is a running average that leans on recent days: at 5 the most recent occurrence is about a third of it and the last five about seven eighths, so a higher number is steadier. An occurrence only joins the average when it is as complete as that hour normally gets - four fifths of a whole hour, or of the most that hour has ever held when the session always clips it short - so a day cut short by a halt does not drag the average down, while an hour that is always half an hour still gets one. The first occurrence of an hour is measured against a whole hour, so joining a chart part-way through one does not seed it.")
bool   showSess   = input.bool(true, "Show sessions on the clock", group = G_CLOCK, tooltip = "A grey dotted arc just outside the rim over each session's hours, with a small box naming it. The hours are read on the same clock as the dial - the Hour timezone below, blank = the exchange's - so with the exchange on New York time the defaults are Asia 19:00-00:00, London 03:00-08:00 and the New York cash session 09:30-16:00. A session that ends earlier in the day than it starts runs over midnight.")
string sessAsia   = input.session("1900-0000", "Asia", group = G_CLOCK)
string sessLon    = input.session("0300-0800", "London", group = G_CLOCK)
string sessNy     = input.session("0930-1600", "New York", group = G_CLOCK)
string tzIn       = input.string("", "Hour timezone (blank = exchange)", options = ["", "UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "America/Sao_Paulo", "Europe/London", "Europe/Berlin", "Europe/Moscow", "Asia/Dubai", "Asia/Kolkata", "Asia/Shanghai", "Asia/Hong_Kong", "Asia/Tokyo", "Australia/Brisbane", "Australia/Sydney", "Pacific/Auckland"], group = G_CLOCK, tooltip = "Which clock the hour numbers, the wedges, the hand and the panel's hour use. Blank = the exchange's own clock, which need not be the timezone your chart's time axis is drawn in. The clock in use is named on the panel's bottom line.")
color  greenCol   = input.color(#1fe08a, "Estimated buying", group = G_LOOK, inline = "c", tooltip = "Estimated buying and selling colour the wedges and the panel's banner and tiles; with candle colouring on, the same two colours mark which side of the trend line each candle closed on. Flat is the colour a window takes when its estimated delta is near zero or could not be measured.")
color  redCol     = input.color(#ff4766, "Estimated selling", group = G_LOOK, inline = "c")
color  greyCol    = input.color(#8a919c, "Flat", group = G_LOOK, inline = "c")
color  lineCol    = input.color(color.white, "Lines", group = G_LOOK, inline = "ln")
int    lineW      = input.int(2, "width", minval = 1, maxval = 4, group = G_LOOK, inline = "ln", tooltip = "Both lines and their tags are drawn in this one colour. Each line's EMA length, in chart bars, is on the tag at its right-hand end.")
bool   showGlow   = input.bool(false, "Halo around the slow line", group = G_LOOK, tooltip = "Off by default, so the two lines stay plain.")
bool   colourBars = input.bool(false, "Colour candles by trend", group = G_LOOK, inline = "cb")
string candleLine = input.string("Fast line", "side of", options = ["Slow line", "Fast line"], group = G_LOOK, inline = "cb", tooltip = "Each candle's colour is the side of this line its close is on: green above it, red below it, so a sustained move through the line turns the candles with it. The colour shows the trend of price, not a buy or sell signal.")
float  flipBuf    = input.float(0.25, "Flip buffer (ATR)", minval = 0, maxval = 2, step = 0.05, group = G_LOOK, tooltip = "How far past the line, in 50-bar ATRs, a candle must close before the colour flips. Inside that band the candle keeps the colour before it, so price chopping around the line does not flip the candles back and forth; a clean break still flips them, a candle or so later than with no buffer. 0 = flip on any close across the line.")
bool   showTags   = input.bool(true, "EMA length tags on the lines", group = G_LOOK)

//------------------------------------------------------------------------------
// Colour and text helpers
//------------------------------------------------------------------------------
mix(color a, color b, float t) =>
    color.from_gradient(t, 0, 1, a, b)

// Relative luminance of a colour, 0..1, on the standard sRGB weights with the gamma curve applied -
// the same quantity the usual contrast ratio is defined from.
relLum(color c) =>
    float r = color.r(c) / 255.0
    float g = color.g(c) / 255.0
    float b = color.b(c) / 255.0
    float rl = r <= 0.03928 ? r / 12.92 : math.pow((r + 0.055) / 1.055, 2.4)
    float gl = g <= 0.03928 ? g / 12.92 : math.pow((g + 0.055) / 1.055, 2.4)
    float bl = b <= 0.03928 ? b / 12.92 : math.pow((b + 0.055) / 1.055, 2.4)
    0.2126 * rl + 0.7152 * gl + 0.0722 * bl

// Contrast ratio between two colours, 1 (identical) to 21 (black on white).
contrastOf(color a, color b) =>
    float la = relLum(a)
    float lb = relLum(b)
    (math.max(la, lb) + 0.05) / (math.min(la, lb) + 0.05)

color INK_DARK  = #06070b
color INK_LIGHT = #f2f4f8
color CHART_BG  = chart.bg_color
color CHART_FG  = chart.fg_color
color NOFILL    = color.new(color.black, 100)
// Which of the two inks actually reads on this chart's background. TradingView keeps a light foreground
// on a mid-grey chart, so anything mixed from the foreground there lands a few percent from the
// background; the readable direction has to be taken from the background itself.
color INK_ON_BG = contrastOf(INK_DARK, CHART_BG) >= contrastOf(INK_LIGHT, CHART_BG) ? INK_DARK : INK_LIGHT

// Text that stands on a fill of its own, whatever colour the user chose for that fill: whichever of the
// near-black and the near-white inks has more contrast on it.
textOn(color fillCol) =>
    contrastOf(INK_DARK, fillCol) >= contrastOf(INK_LIGHT, fillCol) ? INK_DARK : INK_LIGHT

// Pushes a colour away from the chart's background until it clears a contrast target against it, keeping as
// much of the hue as that allows. A colour that already clears the target is returned untouched; one that does
// not - white lines on a white chart, or a fixed palette on a mid grey that lands within a few percent of the
// background - is moved only as far as it takes to read. Where the target cannot be reached at all -
// a mid grey will not carry 4.5:1 from any ink - it returns the most readable colour there is.
lift(color c, float target) =>
    color o = c
    for i = 1 to 7
        if contrastOf(o, CHART_BG) >= target
            break
        o := mix(c, INK_ON_BG, math.min(1.0, i * 0.15))
    o

// The dial's clock in the short form a reader expects. The hour numbers and the stamp are the exchange's
// clock, which is often hours away from the chart's own time axis, so the label has to be on the picture
// rather than assumed. A zone outside the list falls back to its true offset, measured against UTC rather
// than assumed, so the label is never wrong - only less familiar.
tzLabel(string t) =>
    string s = switch t
        "Etc/UTC"             => "UTC"
        "UTC"                 => "UTC"
        "GMT"                 => "UTC"
        "America/New_York"    => "ET"
        "America/Chicago"     => "CT"
        "America/Denver"      => "MT"
        "America/Phoenix"     => "MST"
        "America/Los_Angeles" => "PT"
        "America/Sao_Paulo"   => "BRT"
        "Europe/London"       => "UK"
        "Europe/Berlin"       => "CET"
        "Europe/Paris"        => "CET"
        "Europe/Madrid"       => "CET"
        "Europe/Rome"         => "CET"
        "Europe/Amsterdam"    => "CET"
        "Europe/Zurich"       => "CET"
        "Europe/Stockholm"    => "CET"
        "Europe/Warsaw"       => "CET"
        "Europe/Athens"       => "EET"
        "Europe/Helsinki"     => "EET"
        "Europe/Moscow"       => "MSK"
        "Asia/Dubai"          => "GST"
        "Asia/Kolkata"        => "IST"
        "Asia/Bangkok"        => "ICT"
        "Asia/Shanghai"       => "CST"
        "Asia/Hong_Kong"      => "HKT"
        "Asia/Singapore"      => "SGT"
        "Asia/Tokyo"          => "JST"
        "Asia/Seoul"          => "KST"
        "Australia/Sydney"    => "AET"
        "Australia/Brisbane"  => "AEST"
        "Australia/Perth"     => "AWST"
        "Pacific/Auckland"    => "NZT"
        => ""
    if s == ""
        int off = (hour(time, t) * 60 + minute(time, t)) - (hour(time, "UTC") * 60 + minute(time, "UTC"))
        off := off > 720 ? off - 1440 : off < -720 ? off + 1440 : off
        int ah = math.floor(math.abs(off) / 60)
        int am = math.abs(off) % 60
        s := "UTC" + (off < 0 ? "-" : "+") + str.tostring(ah) + (am > 0 ? ":" + (am < 10 ? "0" : "") + str.tostring(am) : "")
    s

// Names a window given in seconds. Months and weeks are tested before days so a weekly window is not
// relabelled "7D", and anything under a minute keeps its seconds.
tfName(simple int s) =>
    s % 2592000 == 0 ? str.tostring(s / 2592000) + "M" : s % 604800 == 0 ? str.tostring(s / 604800) + "W" : s % 86400 == 0 ? str.tostring(s / 86400) + "D" : s % 3600 == 0 ? str.tostring(s / 3600) + "h" : s % 60 == 0 ? str.tostring(s / 60) + "m" : str.tostring(s) + "s"

//------------------------------------------------------------------------------
// Delta windows and line lengths
//------------------------------------------------------------------------------
int tfSec = timeframe.in_seconds()

// A tick chart's bars have no duration, so an hour of them is not a quantity and the whole dial would
// be meaningless. Tested before anything that divides by the bar length.
if timeframe.isticks
    runtime.error("Delta Pressure Clock needs a time-based chart. Tick bars have no fixed duration, so the hours on the dial would not mean anything.")
// The chart ceiling is tested first and names the chart, so a chart the script cannot work on says so
// instead of blaming three inputs the user never touched.
if tfSec > 3600
    runtime.error("Delta Pressure Clock needs an intraday chart of 1 hour or less. This chart is " + tfName(tfSec) + ".")
if timeframe.in_seconds(tfMidIn) <= timeframe.in_seconds(tfFastIn) or timeframe.in_seconds(tfSlowIn) <= timeframe.in_seconds(tfMidIn)
    runtime.error("Delta Pressure Clock: set Fast shorter than Mid, and Mid shorter than Slow.")
if timeframe.in_seconds(lnSlowIn) <= timeframe.in_seconds(lnFastIn)
    runtime.error("Delta Pressure Clock: set the fast line's timeframe shorter than the slow line's.")

// A window below the chart bar is raised to the chart bar rather than refused, and everything downstream
// is named from the window actually measured.
simple string tfFast = timeframe.in_seconds(tfFastIn) < tfSec ? timeframe.period : tfFastIn
simple string tfMid  = timeframe.in_seconds(tfMidIn)  < tfSec ? timeframe.period : tfMidIn
simple string tfSlow = timeframe.in_seconds(tfSlowIn) < tfSec ? timeframe.period : tfSlowIn
simple string lnFast = timeframe.in_seconds(lnFastIn) < tfSec ? timeframe.period : lnFastIn
simple string lnSlow = timeframe.in_seconds(lnSlowIn) < tfSec ? timeframe.period : lnSlowIn

// Whole chart bars in a window. The raise above guarantees at least one, and the ceiling keeps the rolling
// sum inside the history TradingView will hold.
ratioOf(simple string tf) =>
    math.min(4800, math.round(timeframe.in_seconds(tf) / tfSec))

// The EMA length comes from the unrounded time ratio and is rounded once, so a chart timeframe that does
// not divide the window does not drag the line off its stated length. 2 is a live floor: a 1-bar EMA is
// just the close.
lenOf(simple string tf) =>
    math.max(2, math.round(lineMult * timeframe.in_seconds(tf) / tfSec))

simple int rFast0 = ratioOf(tfFast)
simple int rMid0  = ratioOf(tfMid)
simple int rSlow0 = ratioOf(tfSlow)
// Two windows that round to the same number of chart bars would be measured twice and reported as two
// agreeing readings, so they are nudged apart and renamed from the window really used. Nudging and capping
// happen in one step, each ceiling one below the next, so the cap can never undo the nudge and leave two
// windows on the same length or the mid window longer than the slow one.
simple int rFast  = math.min(4798, rFast0)
simple int rMid   = math.min(4799, math.max(rMid0, rFast + 1))
simple int rSlow  = math.min(4800, math.max(rSlow0, rMid + 1))

// The two lines' EMA lengths, nudged apart and capped the same way.
simple int dFast  = lenOf(lnFast)
simple int dSlow  = lenOf(lnSlow)
simple int lenFast = math.min(4798, dFast)
simple int lenSlow = math.min(4800, math.max(dSlow, lenFast + 1))
// rSlow is not tested: its ceiling is the highest, so it can never come back below the value ratioOf already
// capped.
bool lineCut = lenFast < dFast or lenSlow < dSlow or rFast < rFast0 or rMid < rMid0

string nameFast = tfName(rFast * tfSec)
string nameMid  = tfName(rMid * tfSec)
string nameSlow = tfName(rSlow * tfSec)

//------------------------------------------------------------------------------
// Estimated delta for the bar
//------------------------------------------------------------------------------
// Auto lower timeframe: the coarsest whole minute that still gives about five short bars per chart bar
// and divides it exactly, so the estimate is fine enough to mean something without asking for far more
// intrabars than it needs. On charts of 5 minutes and under that is 1 minute; on 1 minute and below the
// chart bar itself is signed, so the request is never issued there.
simple int tfMin = math.max(1, math.round(tfSec / 60.0))
simple int want  = math.max(1, math.floor(tfMin / 5.0))
// The largest whole minute no bigger than that target which still divides the chart bar - larger means
// fewer, coarser intrabars and more history. Written as one expression rather than a loop so it stays a
// simple value, which is what the data request needs.
simple int autoMin = want >= 12 and tfMin % 12 == 0 ? 12 : want >= 11 and tfMin % 11 == 0 ? 11 : want >= 10 and tfMin % 10 == 0 ? 10 : want >= 9 and tfMin % 9 == 0 ? 9 : want >= 8 and tfMin % 8 == 0 ? 8 : want >= 7 and tfMin % 7 == 0 ? 7 : want >= 6 and tfMin % 6 == 0 ? 6 : want >= 5 and tfMin % 5 == 0 ? 5 : want >= 4 and tfMin % 4 == 0 ? 4 : want >= 3 and tfMin % 3 == 0 ? 3 : want >= 2 and tfMin % 2 == 0 ? 2 : 1

// A chart bar that is not a whole number of minutes cannot be tiled by minute bars at all, so it is
// signed on its own rather than being cut by bars that straddle its boundaries.
simple string ltfAuto = tfSec <= 60 or tfSec % 60 != 0 ? timeframe.period : str.tostring(autoMin)
simple string ltf     = ltfIn == "" ? ltfAuto : ltfIn
simple bool   useBar  = timeframe.in_seconds(ltf) >= tfSec

// A lower timeframe the user typed is held to the same rule on every chart: shorter than the chart bar,
// and dividing it evenly, or its short bars straddle the bar boundaries and spill volume into a neighbour.
// On a chart of a minute or less the automatic choice IS the chart bar, so there is no shorter value to
// recommend and the message only offers the blank.
simple string ltfHint = timeframe.in_seconds(ltfAuto) < tfSec ? ", or use " + ltfAuto : ""
if ltfIn != "" and timeframe.in_seconds(ltfIn) >= tfSec
    runtime.error("Delta Pressure Clock: the lower timeframe must be shorter than this chart's " + tfName(tfSec) + " bar. Leave it blank" + ltfHint + ".")
if ltfIn != "" and (timeframe.in_seconds(ltfIn) <= 0 or tfSec % timeframe.in_seconds(ltfIn) != 0)
    runtime.error("Delta Pressure Clock: " + ltfIn + " does not divide this chart's " + tfName(tfSec) + " bar evenly. Leave it blank" + ltfHint + ".")

array<float> ltfOpen  = na
array<float> ltfClose = na
array<float> ltfVol   = na
if not useBar
    [reqO, reqC, reqV] = request.security_lower_tf(syminfo.tickerid, ltf, [open, close, volume], ignore_invalid_timeframe = true)
    ltfOpen  := reqO
    ltfClose := reqC
    ltfVol   := reqV

// Each short bar's whole volume is signed by its close against the previous short bar's close - the tick rule,
// applied to short bars - so a move between two bars counts as well as a move within one. A bar that closed
// level keeps the side of the one before it. The short bars arrive oldest first, which is what makes the
// carry run forward in time, and the last close is kept across chart bars. Both start again at every session,
// where the first short bar has no previous close and is measured against its own open. The carry is cleared
// whenever a bar brings no data, so "no direction yet" and "no direction any more" are the same honest state
// and contribute nothing.
var int   carryDir = 0
var float lastC    = na
if session.isfirstbar
    carryDir := 0
    lastC    := na
float barDelta  = 0.0
float signedVol = 0.0
if useBar
    float refB = na(lastC) ? open : lastC
    carryDir := close > refB ? 1 : close < refB ? -1 : carryDir
    lastC    := close
    barDelta := carryDir * nz(volume)
    signedVol := carryDir != 0 ? nz(volume) : 0.0
else if not na(ltfClose)
    // The size test is essential: Pine's for loop counts backwards over an empty array.
    if ltfClose.size() > 0
        bool anyRead = false
        for i = 0 to ltfClose.size() - 1
            float io = ltfOpen.get(i)
            float ic = ltfClose.get(i)
            // A short bar with no prices contributes nothing rather than a carried load. The tuple request
            // returns arrays of equal length, so one index is safe across all three.
            if not na(io) and not na(ic)
                float ref = na(lastC) ? io : lastC
                carryDir := ic > ref ? 1 : ic < ref ? -1 : carryDir
                lastC    := ic
                anyRead  := true
                if carryDir != 0
                    float iv = nz(ltfVol.get(i))
                    barDelta += carryDir * iv
                    signedVol += iv
        // An array that arrived but held no readable price is no data either.
        if not anyRead
            carryDir := 0
    else
        carryDir := 0
else
    carryDir := 0

// How much signed volume the whole loaded history carries, and how long the stream has been running, so a
// chart with no usable intrabar data can say so instead of drawing it as a balanced market.
var float cumSigned = 0.0
var int   barsFlow  = 0
cumSigned += signedVol
if signedVol > 0 or barsFlow > 0
    barsFlow += 1
bool noFlow  = cumSigned == 0
bool badType = not chart.is_standard

//------------------------------------------------------------------------------
// Delta per window: each window's smoothed delta drives its tile
//------------------------------------------------------------------------------
// Net estimated delta over the window, smoothed, then scaled to -1..+1 by the 90th percentile of how big
// that smoothed value has been. The lookback is stretched to several times the signal's own memory so the
// percentile is not drawn from a handful of swings, and the whole thing stays na until the window has been
// full of measured bars, so a window with no scale yet reads flat instead of confident.
deltaTone(simple int ratio) =>
    float raw   = math.sum(barDelta, ratio)
    simple int sm  = math.round(4 * math.sqrt(ratio))
    simple int eff = math.min(4000, math.max(normLen, 6 * (ratio + 3 * sm)))
    float s     = ta.ema(raw, sm)
    float scale = ta.percentile_nearest_rank(math.abs(s), eff, 90)
    // barsFlow only leaves zero once a bar has carried signed volume, and eff is at least normLen's
    // minimum of 50, so reaching the window implies the stream is not empty - noFlow is not retested.
    scale > 0 and barsFlow >= eff ? math.max(-1.0, math.min(1.0, s / scale)) : na

// On a chart whose opens and closes are manufactured the estimate does not apply, so the readings are
// dropped at the source and everything downstream - tiles, candles, wedges - goes to its flat state.
float uFast = badType ? na : deltaTone(rFast)
float uMid  = badType ? na : deltaTone(rMid)
float uSlow = badType ? na : deltaTone(rSlow)

float emaFast = ta.ema(close, lenFast)
float emaSlow = ta.ema(close, lenSlow)
// One 50-bar ATR, used in three places: the halo's thickness, how close the two lines must run before their tags
// split apart, and the candles' flip buffer.
float atr     = ta.atr(50)

//------------------------------------------------------------------------------
// Hour buckets: this 24 hours, and the typical hour
//------------------------------------------------------------------------------
string tz     = tzIn == "" ? syminfo.timezone : tzIn
string tzLab  = tzLabel(tz)
int    curH   = hour(time, tz)
// The absolute start of the hour this bar opened in. Keying the occurrence on this rather than on the hour
// number keeps it right in half-hour-offset zones and across a clock change, and makes the stamp exact.
int    hStart = time - (minute(time, tz) * 60 + second(time, tz)) * 1000
// The start of the day the bar opened in, on the same clock. While the chart loads its history the wedge scale
// is refreshed on this boundary and on no other, so an hour roll can never move it.
int    dStart = time - ((hour(time, tz) * 60 + minute(time, tz)) * 60 + second(time, tz)) * 1000
int    barsPerHour = math.max(1, math.round(3600.0 / tfSec))
int    RING = 60

var array<float> bucket  = array.new<float>(24, 0.0)       // net estimated delta of the most recent occurrence of each hour
var array<int>   stamp   = array.new<int>(24, 0)           // the start of the hour that occurrence began in
var array<int>   slot    = array.new<int>(24, 0)           // which hour slot that occurrence was, counted from the first bar
var array<int>   nBar    = array.new<int>(24, 0)           // confirmed bars in that occurrence, to tell a full hour from a clipped one
var array<float> typ     = array.new<float>(24, na)        // exponential average of finished hours across days, weighted to the last typDays
var array<float> prevTyp = array.new<float>(24, na)        // the same average as it stood BEFORE the current occurrence was folded in
var array<int>   seen    = array.new<int>(24, 0)           // how many finished occurrences of that hour are behind the average
var array<int>   bestBar = array.new<int>(24, 0)           // the most confirmed bars that hour has ever held, so an hour the session always clips is still judged fairly
var array<int>   upRing  = array.new<int>(24 * RING, 0)    // 1 where a finished occurrence was net buying, -1 net selling, 0 level
var array<float> magRing = array.new<float>(24 * RING, na) // the SIZE of each finished occurrence, which is what the wedge scale is built from
var array<int>   ringPos = array.new<int>(24, 0)
var int lastH   = na
var int lastHS  = na
var int slotNo  = 0
// The wedge scale and the day it was last measured on.
var float wedgeRef = na
var int   refDay   = na

if barstate.isconfirmed
    if na(lastHS) or hStart != lastHS
        // An occurrence is folded in only when it is as complete as that hour normally gets: four fifths of
        // a whole hour, or of the most this hour has ever held when the session always clips it short. The
        // first occurrence of an hour is measured against a whole hour, so a part hour joined part-way
        // through cannot seed the average, and an hour the session clips every day still earns one from
        // its second occurrence onward.
        if not na(lastH)
            int cov   = nBar.get(lastH)
            int prior = bestBar.get(lastH)
            int needBar = math.max(1, math.round(0.8 * (prior > 0 ? math.min(barsPerHour, prior) : barsPerHour)))
            if cov >= needBar
                float done = bucket.get(lastH)
                float old  = typ.get(lastH)
                prevTyp.set(lastH, old)
                typ.set(lastH, na(old) ? done : old + (done - old) * 2.0 / (typDays + 1))
                int p = ringPos.get(lastH)
                upRing.set(lastH * RING + p, done > 0 ? 1 : done < 0 ? -1 : 0)
                magRing.set(lastH * RING + p, math.abs(done))
                ringPos.set(lastH, (p + 1) % RING)
                seen.set(lastH, math.min(RING, seen.get(lastH) + 1))
            else
                // Nothing was folded in, so the frozen baseline is still the live average.
                prevTyp.set(lastH, typ.get(lastH))
            // The reference tracks the recent maximum rather than the all-time one: it rises to a longer
            // occurrence at once but gives a bar back each time, so one unusual day cannot lock an hour
            // out of its own average for good.
            bestBar.set(lastH, math.max(cov, prior - 1))
        slotNo += 1
        bucket.set(curH, 0.0)
        stamp.set(curH, hStart)
        slot.set(curH, slotNo)
        nBar.set(curH, 0)
        lastH  := curH
        lastHS := hStart
    bucket.set(curH, bucket.get(curH) + barDelta)
    nBar.set(curH, nBar.get(curH) + 1)

    // The wedge scale. Every finished occurrence the chart still holds - up to RING of each hour, about two
    // months - is one reading, and the scale is the 90th percentile of their sizes. While the chart loads its
    // history it is measured each time the date turns over on the dial's own clock, so the population it is
    // drawn from never changes at an hour boundary. Once the chart is live it is frozen, like the price per
    // bar, so a finished wedge never changes length while the chart is open. The one exception is a chart
    // with no scale at all yet: it takes one as soon as the first hour has finished, rather than drawing an
    // empty dial until it is reloaded.
    bool newDay = na(refDay) or dStart != refDay
    if (newDay and not barstate.isrealtime) or na(wedgeRef)
        array<float> refMag = array.new<float>()
        for i = 0 to 24 * RING - 1
            float x = magRing.get(i)
            if not na(x) and x > 0
                refMag.push(x)
        wedgeRef := refMag.size() >= 8 ? refMag.percentile_nearest_rank(90) : refMag.size() > 0 ? 1.2 * refMag.max() : na
        refDay  := dStart

// The clock and panel are drawn only on closed bars, so the hour in progress is read from its bucket, which
// already holds every closed bar of that hour.
bool  haveNow = not na(lastHS) and lastHS == hStart
float nowVal  = haveNow ? bucket.get(curH) : 0.0

// Candles in the colour of the trend: the side of the chosen line the close is on, so they can never point
// against a sustained move. Measured at that candle's close and no later, so a closed candle never changes
// colour. To flip, a close has to clear the line by the buffer; a close inside that band keeps the side before it,
// so chop around the line does not flip the colour. Before the line exists, and on a chart whose opens and
// closes are manufactured, candles are left alone.
float trendLn = candleLine == "Fast line" ? emaFast : emaSlow
float bufP    = flipBuf * nz(atr)
var int trendSide = 0
if not na(trendLn)
    trendSide := close > trendLn + bufP ? 1 : close < trendLn - bufP ? -1 : trendSide
// Drawn as candles of the script's own over the chart's, so the body, the wick and the outline all take the
// colour. They come before the lines, so the lines stay on top of them.
bool  paintC   = colourBars and not badType and trendSide != 0
color candleC  = trendSide > 0 ? color.new(greenCol, 0) : color.new(redCol, 0)
plotcandle(paintC ? open : na, paintC ? high : na, paintC ? low : na, paintC ? close : na, "Trend candles", candleC, candleC, editable = false, bordercolor = candleC, display = display.pane)

// Halo around the slow line, taken from the chart's own colours so it is visible on a light chart as well
// as a dark one, and never thinner than the line it surrounds.
color glowCol = mix(CHART_BG, CHART_FG, 0.55)
float glowW   = math.max(nz(atr) * 0.12, syminfo.mintick * 2)
glowTop = plot(showGlow and not na(emaSlow) ? emaSlow + glowW : na, "Slow line halo top", glowCol, editable = false, display = display.none)
glowBot = plot(showGlow and not na(emaSlow) ? emaSlow - glowW : na, "Slow line halo bottom", glowCol, editable = false, display = display.none)
fill(glowTop, glowBot, color.new(glowCol, 72), "Slow line halo", editable = true)
// The two lines, one colour and one width. Each one's EMA length is on its tag. The chosen colour is held to
// the main text's contrast floor, so white on a dark chart is untouched and still reads on a light one.
color lineUse = lift(lineCol, 4.5)
plot(emaSlow, "Slow line", lineUse, linewidth = lineW, editable = false)
plot(emaFast, "Fast line", lineUse, linewidth = lineW, editable = false)

//------------------------------------------------------------------------------
// Clock geometry: bar columns on the x axis, price on the y axis, to the right of the last bar
//------------------------------------------------------------------------------
// Design constants, as shares of the clock's radius unless stated otherwise.
float R_HUB    = 0.177    // hub
float R_MID    = 0.59     // middle ring
float R_PAD    = 0.32     // shortest wedge
float R_LEN    = 0.68     // the strongest hours reach the rim: R_PAD + R_LEN = 1
float RIM_GAP  = 0.75     // ...less this many columns, so a full wedge's bright tip sits clear of the rim line
// A stronger wedge's full-colour core is decoration, so its end may sit anywhere in a range around CORE_MID of
// the wedge's length past the hub: it takes the circle in that range whose corners land closest to the grid.
float CORE_MID = 0.80
int   TIP_PX   = 6        // the bright tip of the strongest wedges, in pixels
float SNAP     = 0.03     // how far a wedge tip may move, in radii, for its end's corners to land on its circle
float SNAP_MAX = 0.05     // ...and how far when 3% still leaves a corner more than ERR_OK off it
float ERR_OK   = 0.05     // a corner this close to its end's circle, in columns, counts as on it
float EDGE     = 0.20     // the furthest a corner may sit across its hour line, in columns; GAP_W covers it
float GAP_W    = 0.42     // the background band down each hour line, in columns: just wider than 2 x EDGE
float BASE_MIX = 0.35     // the outer part of a wedge: its colour this far from the chart background
float R_HAND   = 1.047    // the hand reaches just past the rim
float R_NUM    = 1.125    // centres of the hour numbers
float R_SESS   = 1.055    // the dotted session arcs, just outside the rim and inside the hour numbers
float ARC_TOL  = 0.85     // how far off its circle an exact-angle arc end may sit, in columns
float R_SLAB   = 1.24     // session label boxes: their inner edge sits at least this far out, clear of the numbers
float SLAB_H   = 0.042    // ...half a label box's height
bool  sessOn   = showClock and showSess
float U_CORE   = 0.23     // strength from which a wedge has a full-colour core
float U_TIP    = 0.60     // strength from which it also has the bright tip
int   SEP_PX   = 4        // the least width of the gap between neighbouring wedges, in pixels
float STEP     = 2 * math.pi / 24
// Layout around the clock, in radii from its centre: the panel below it.
float PAN_TOP  = 1.602    // top of the panel
float PANW     = 0.926    // panel half-width
float PAN_H    = 0.976    // panel height
float ROW_NOTE = 0.092    // an extra row at the panel's foot, one for each note there is to make
int   nNotes   = (badType ? 1 : 0) + (lineCut ? 1 : 0)
float PANU     = PAN_H + nNotes * ROW_NOTE
// The dial's lines and the panel's frame are one cool slate at different strengths.
color SLATE    = #57708a
// The text palette that reads on this chart: the design's own greys on a dark chart, darker ones on a light one.
bool  DARK_UI  = contrastOf(INK_LIGHT, CHART_BG) > contrastOf(INK_DARK, CHART_BG)
// Each is held to a contrast floor against this chart's background. On a light chart and on pure black the
// palette already clears every floor and is untouched; on TradingView's dark and the mid greys the hour numbers
// and the panel's two footer rows are lifted to 3:1, so they can still be read off a screenshot.
color TXT_MAIN = lift(DARK_UI ? #d7dbe0 : #1f242b, 4.5)
color TXT_SOFT = lift(DARK_UI ? #9aa3ad : #4b535e, 3.0)
color TXT_DIM  = lift(DARK_UI ? #5a626d : #69717c, 3.0)
color TXT_NUM  = lift(DARK_UI ? #565d68 : #747c87, 3.0)

// Top of the picture is the ring of hour numbers; the panel's top drops below the clock when the clock is shown.
float topU     = sessOn ? R_SLAB + 2 * SLAB_H + 0.04 : showClock ? R_NUM + 0.085 : 0.04
float dropU    = showClock ? PAN_TOP : 0.0

// Auto size: the clock's price per bar, so the radius input changes how big the clock is and not the shape
// of the picture. The lookback shortens itself on a chart with less history, so the radius is never na and
// the whole figure cannot collapse onto price zero. The floor is a few ticks rather than an ATR, so the
// share stays authoritative and the only thing guarded against is a flat range. The value is frozen once
// measured: with Lock price to bar ratio ticked, the clock's shape on screen depends only on its price per
// bar, so a value that moved would stretch the clock every time it did.
int   useLen  = math.min(rangeLen, bar_index + 1)
float rangeH  = ta.highest(high, useLen)
float rangeL  = ta.lowest(low, useLen)
float ppbRaw  = math.max(clockShare * nz(rangeH - rangeL), 20 * syminfo.mintick) / 48.0
// Measured once, on the last historical bar - the bar the clock is first drawn on - and never moved after
// that. Historical bars draw nothing, so the live value only stands in for them.
var float ppbFix = na
if barstate.islastconfirmedhistory
    ppbFix := ppbRaw
float ppbUse = ppbIn > 0 ? ppbIn : nz(ppbFix, ppbRaw)
float rP     = ppbUse * dialR

// Centre: on the last close, or half the clock's radius above or below it.
float cy     = posIn == "Above price" ? close + 0.5 * rP : posIn == "Below price" ? close - 0.5 * rP : close
// How far the drawing reaches from its centre, as a column count: the ring of hour numbers, plus a text margin
// that scales with the clock. TradingView will not place anything more than 500 bars past the last candle, so
// the gap is trimmed to whatever still fits rather than the whole indicator being refused.
int   marg   = math.max(2, math.round(0.04 * dialR))
// With the clock switched off only the panel is drawn, so it does not have to reserve the clock's width. The
// session labels reach furthest, a box's full width out at the side of the dial.
int   far    = sessOn ? math.round(1.6 * dialR) : showClock ? math.round((R_NUM + 0.085) * dialR) : math.round(PANW * dialR)
int   need   = 2 * far + marg
// 500 is TradingView's hard limit on how far right of the last candle an object may be placed. need is
// at most 486 at the widest legal clock, so the trim can never push the gap below 14.
int   gapOk  = math.min(clockGap, 500 - need)
int   c0     = bar_index + gapOk + far + marg
// The hub is a whole number of columns, so it can be drawn as a true circle, and the wedge cores are
// measured from it.
int   rh     = math.round(R_HUB * dialR)

//------------------------------------------------------------------------------
// Geometry helpers
//------------------------------------------------------------------------------
// Wedge length for a strength share u (0..1, clamped by both callers), on a square-root scale so quiet hours
// stay visible.
wedgeLen(float u) =>
    R_PAD + R_LEN * math.sqrt(u)

// Rounds half away from zero, so a point and its mirror image across the dial's axis land on mirrored
// columns and the clock is symmetric left to right and top to bottom. The tiny allowance makes an exact half
// round the same way on both sides: the sine of 30 degrees comes out a hair under one half and the sine of
// 330 a hair over, which would otherwise send two mirrored corners to columns one apart.
symRound(float x) =>
    x >= 0 ? math.floor(x + 0.5 + 1e-9) : -math.floor(-x + 0.5 + 1e-9)

// A circle of rb columns about the centre, drawn with straight segments through one point per column, plus an
// upright run on the outermost column at each side. Near the sides the circle runs almost straight down the
// columns, so all of its turn there would fall on the run's two ends and read as a corner. With lift, the run is
// shorter and the two columns next to each side are raised a fraction off the circle, so that turn is shared
// over three corners. The run and the lift together take about a seventh off the sharpest corner (the run does
// most of that; the lift itself is worth three to four percent), and the drawing stays within a tenth of a
// column of the true circle. The three amounts were fitted for minimum sharpest corner at every radius from 12
// to 150 columns. The rim, the middle ring, the hub and every wedge end are built this way.
// How far the column j in from the side of a circle of rb columns is raised, in columns.
liftAt(int rb, int j) =>
    float s = rb / 21.0
    j == 1 ? 0.196 * math.pow(s, 0.36) : j == 2 ? 0.23 * math.pow(s, 0.37) : 0.0

// Height of the drawn circle at column k (|k| < rb), in columns.
circY(int rb, int k) =>
    math.sqrt(rb * rb - k * k) + liftAt(rb, rb - math.abs(k))

// Height of the upright run's ends on the outermost column, in columns.
sideH(int rb) =>
    1.332 * math.sqrt(rb / 21.0)

circlePts(int rb) =>
    float q  = rP / dialR
    float hs = sideH(rb) * q
    array<chart.point> pts = array.new<chart.point>()
    for k = -rb + 1 to rb - 1
        pts.push(chart.point.from_index(c0 + k, cy + circY(rb, k) * q))
    pts.push(chart.point.from_index(c0 + rb, cy + hs))
    pts.push(chart.point.from_index(c0 + rb, cy))
    pts.push(chart.point.from_index(c0 + rb, cy - hs))
    for k = rb - 1 to -rb + 1
        pts.push(chart.point.from_index(c0 + k, cy - circY(rb, k) * q))
    pts.push(chart.point.from_index(c0 - rb, cy - hs))
    pts.push(chart.point.from_index(c0 - rb, cy))
    pts.push(chart.point.from_index(c0 - rb, cy + hs))
    pts

// A point at column offset k and price offset y from the centre.
pt(int k, float y) =>
    chart.point.from_index(c0 + k, cy + y)

// A point at column offset k and a height of y columns from the centre.
ptc(int k, float y) =>
    pt(k, y * rP / dialR)

// The point at exactly `ang` degrees clockwise from the top on a circle of rb columns, put on a whole bar
// column. The price on a column is free, so a point on any column can sit exactly on a line through the
// centre: take the column nearest where that line crosses the circle and let the price follow the angle.
// The direction is then exact and only the radius steps, by a column over the sine of the angle. Straight
// up and straight down the centre column carries every radius, so those are exact in both. Returns the
// column, its height in columns, and how far the radius misses the circle - the caller uses the point only
// when that miss is small enough to be invisible.
exactOn(int rb, float ang) =>
    float s = math.sin(ang * math.pi / 180)
    float c = math.cos(ang * math.pi / 180)
    int   k = 0
    float y = 0.0
    float e = 1e9
    if math.abs(s) < 1e-9
        y := c >= 0 ? rb : -rb
        e := 0.0
    else
        k := symRound(rb * s)
        if k != 0
            y := k * c / s
            e := math.abs(math.sqrt(k * k + y * y) - rb)
    [k, y, e]

// The part of that same drawn circle - the same points, lift and upright side runs - that lies between a0 and
// a0 + span degrees clockwise from the top, in order. Built from the circle's own points, so an arc on it has
// exactly the rim's curve, and each end is carried out to the exact angle asked for.
ringArc(int rb, float a0, float span) =>
    float hs = sideH(rb)
    array<int>   ks = array.new<int>()
    array<float> ys = array.new<float>()
    for k = -rb + 1 to rb - 1
        ks.push(k)
        ys.push(circY(rb, k))
    ks.push(rb)
    ys.push(hs)
    ks.push(rb)
    ys.push(0.0)
    ks.push(rb)
    ys.push(-hs)
    for k = rb - 1 to -rb + 1
        ks.push(k)
        ys.push(-circY(rb, k))
    ks.push(-rb)
    ys.push(-hs)
    ks.push(-rb)
    ys.push(0.0)
    ks.push(-rb)
    ys.push(hs)
    array<int>   kSel = array.new<int>()
    array<float> ySel = array.new<float>()
    array<float> dSel = array.new<float>()
    // The vertices either side of the sweep are kept as well, as a fallback. Taking only the first vertex
    // inside the sweep starts the arc late and ends it early by up to a whole vertex, and next to the sides,
    // where one column covers a lot of angle, that is most of an hour's width at a small Size.
    int   kPrev = 0
    float yPrev = 0.0
    float dPrev = -1.0
    int   kNext = 0
    float yNext = 0.0
    float dNext = -1.0
    for i = 0 to ks.size() - 1
        // Clockwise from the top: Pine has no two-argument arctangent, so the quadrant is taken from the signs.
        int   kk  = ks.get(i)
        float yy  = ys.get(i)
        float ang = yy > 0 ? math.atan(kk / yy) * 180 / math.pi : yy < 0 ? math.atan(kk / yy) * 180 / math.pi + 180 : kk > 0 ? 90.0 : 270.0
        float d   = ang - a0 - 360 * math.floor((ang - a0) / 360)
        if d <= span
            kSel.push(kk)
            ySel.push(yy)
            dSel.push(d)
        else
            // Outside the sweep: the largest d is the vertex just before the start, the smallest the one
            // just after the end.
            if d > dPrev
                dPrev := d
                kPrev := kk
                yPrev := yy
            if dNext < 0 or d < dNext
                dNext := d
                kNext := kk
                yNext := yy
    // Each end is carried out to the exact angle asked for, on the whole column nearest where that angle
    // crosses the circle. That lands the end of the arc on its session's own hour line rather than on the
    // nearest vertex, at the cost of the radius stepping by up to ARC_TOL of a column - well under a tenth
    // of the gap between the rim and the hour numbers, and invisible against a dotted line. Where the grid
    // has no column that close, the end falls back to the nearer of the two vertices around it; that only
    // happens within a couple of hours of the top and bottom of the dial, where the vertices are already
    // about a degree apart and the fallback is within half of that.
    [kS, yS, eS] = exactOn(rb, a0)
    [kE, yE, eE] = exactOn(rb, a0 + span)
    array<chart.point> out = array.new<chart.point>()
    if dSel.size() > 0
        array<int> ord = dSel.sort_indices(order.ascending)
        float dFirst = dSel.get(ord.first())
        float dLast  = dSel.get(ord.last())
        if eS <= ARC_TOL
            out.push(ptc(kS, yS))
        else if dPrev >= 0 and 360 - dPrev < dFirst
            out.push(ptc(kPrev, yPrev))
        for j in ord
            out.push(ptc(kSel.get(j), ySel.get(j)))
        if eE <= ARC_TOL
            out.push(ptc(kE, yE))
        else if dNext >= 0 and dNext - span < span - dLast
            out.push(ptc(kNext, yNext))
    out

// Wedge ends. A wedge end is a slice of a circle about the centre, between two hour lines. The geometry is
// worked in the first quadrant only - hour lines 0 to 6 there, 0 straight up and 6 straight right - and
// mirrored out, so mirrored wedges are the same shape to the last digit. Quadrant line j's sine and cosine,
// exact at 0 and 6.
lineS(int j) =>
    j == 0 ? 0.0 : j == 6 ? 1.0 : math.sin(j * STEP)

lineC(int j) =>
    j == 0 ? 1.0 : j == 6 ? 0.0 : math.cos(j * STEP)

// The bar columns where quadrant line j can carry a corner at a radius between rlo and rhi columns.
cornerK0(int j, float rlo) =>
    j == 6 ? math.max(1, math.floor(rlo) - 1) : math.max(1, math.floor(rlo * lineS(j)) - 1)

cornerK1(int j, float rhi) =>
    j == 6 ? math.ceil(rhi) + 1 : math.ceil(rhi * lineS(j)) + 1

// The heights on column k within EDGE of quadrant line j, and the radii they span: a corner may sit anywhere on
// that stretch. The horizontal line takes its corner on the line itself.
cornerSpan(int j, int k) =>
    float s  = lineS(j)
    float c  = lineC(j)
    float ya = j == 6 ? 0.0 : math.max(0.0, (k * c - EDGE) / s)
    float yb = j == 6 ? 0.0 : (k * c + EDGE) / s
    [ya, yb, math.sqrt(k * k + ya * ya), math.sqrt(k * k + yb * yb)]

// The radius spans of every corner stretch on quadrant line j between rlo and rhi columns, as pairs.
spans(int j, float rlo, float rhi) =>
    array<float> out = array.new<float>()
    if j > 0
        for k = cornerK0(j, rlo) to cornerK1(j, rhi)
            [ya, yb, r1, r2] = cornerSpan(j, k)
            out.push(r1)
            out.push(r2)
    out

// How far a circle of radius r columns misses the nearest corner stretch on quadrant line j. The vertical line
// always has its corner on the circle, straight above the centre.
// spans() builds the stretches in column order and both ends of a stretch grow with the column, so the list
// is sorted by radius. Once the circle falls short of a stretch every later one is further away still, and
// once it lands inside one the miss is zero and cannot be beaten, so the scan stops at the first of either.
// Same answer as reading the whole list, for a little over half the work on the deepest Size.
lineErr(int j, float r, array<float> sp) =>
    float e = j == 0 ? 0.0 : 1e9
    if sp.size() > 0
        for i = 0 to sp.size() - 2 by 2
            float r1 = sp.get(i)
            float r2 = sp.get(i + 1)
            if r < r1
                e := math.min(e, r1 - r)
                break
            else if r > r2
                e := math.min(e, r - r2)
            else
                e := 0.0
                break
    e

// A wedge end: the radius of its circle in columns, how far its worse corner misses that circle, and the score
// it won with.
type ArcPick
    float rho
    float err
    float score

// The circle, of radius between lo and hi columns and as close to r0 as the rest allows, whose two corners on
// quadrant lines q and q + 1 land closest to it. The miss is the larger of two distances that are each zero
// over some stretches of radius and rise in straight lines outside them, so the best radius is always r0, an
// end of the range, an end of a stretch, or halfway between an end on one line and an end on the other: those
// are all the candidates, and the first best one wins.
arcSearch(int q, float lo, float hi, float r0) =>
    array<float> sa = spans(q, lo - 1, hi + 1)
    array<float> sb = spans(q + 1, lo - 1, hi + 1)
    array<float> cand = array.from(r0, lo, hi)
    cand.concat(sa)
    cand.concat(sb)
    for a in sa
        for b in sb
            cand.push((a + b) / 2)
    float bs = na
    float br = r0
    float be = 0.0
    for r in cand
        if r >= lo - 1e-9 and r <= hi + 1e-9
            float e  = math.max(lineErr(q, r, sa), lineErr(q + 1, r, sb))
            float sc = e + 0.001 * math.abs(r - r0)
            if na(bs) or sc < bs - 1e-12
                bs := sc
                br := r
                be := e
    ArcPick.new(br, be, bs)

// The corner of a circle of radius r columns on quadrant line j: on the column whose stretch comes nearest the
// circle, at the height where it meets the circle, or at the nearest end of the stretch if it does not.
arcCorner(int j, float r) =>
    int   bk = 0
    float bY = r
    if j > 0
        float bd = na
        for k = cornerK0(j, r - 1) to cornerK1(j, r + 1)
            [ya, yb, r1, r2] = cornerSpan(j, k)
            float d = r < r1 ? r1 - r : r > r2 ? r - r2 : 0.0
            if na(bd) or d < bd - 1e-12
                bd := d
                bk := k
                bY := math.min(math.max(math.sqrt(math.max(0.0, r * r - k * k)), ya), yb)
    [bk, bY]

// The radius of that corner, for the gap bands.
cornerR(int j, float r) =>
    [k, y] = arcCorner(j, r)
    math.sqrt(k * k + y * y)

// The end of the wedge in quadrant q, on a circle of radius r columns, from its corner on line q to its corner on
// line q + 1, mirrored out by sx and sy. Between the corners it has one point per column exactly on the circle.
// The quadrant-5 wedge ends on the horizontal line, where a circle runs almost straight down the columns, so it
// is drawn there exactly as the rim is: the two columns before the side raised a fraction, and a short upright
// run on the side column down to the corner.
arcPts(int q, float r, int sx, int sy) =>
    [k1, y1] = arcCorner(q, r)
    [k2, y2] = arcCorner(q + 1, r)
    array<chart.point> pts = array.from(ptc(sx * k1, sy * y1))
    float lastY = y1
    if k2 - k1 >= 2
        for k = k1 + 1 to k2 - 1
            float y = math.sqrt(math.max(0.0, r * r - k * k)) + (q == 5 ? liftAt(k2, k2 - k) : 0.0)
            pts.push(ptc(sx * k, sy * y))
            lastY := y
    if q == 5
        float hs = sideH(k2)
        if hs < lastY - 1e-9
            pts.push(ptc(sx * k2, sy * hs))
    pts.push(ptc(sx * k2, sy * y2))
    pts

// The column of the point of hour line b that lies exactly on the line, at the smallest radius of at least rmin
// columns: the price is free, so a point on any column can sit exactly on a line through the centre, but the
// radius then comes in steps of a column over the sine of the line's angle. A vertical line has every radius
// on its centre column.
exactK(int b, float rmin) =>
    float s = math.sin(b * STEP)
    int   m = math.ceil(rmin * math.abs(s) - 1e-9)
    math.abs(s) < 1e-9 ? 0 : s > 0 ? m : -m

// That point.
exactPt(int b, float rmin) =>
    float s = math.sin(b * STEP)
    float c = math.cos(b * STEP)
    int   k = exactK(b, rmin)
    pt(k, (math.abs(s) < 1e-9 ? (c > 0 ? rmin : -rmin) : k * c / s) * rP / dialR)

// A background patch over the rim's outside where hour line b crosses it, out past the spoke's end and past the
// rim's raised columns. Its inner edge runs through the rim's own points, so a spoke drawn past the rim is cut
// off exactly at it.
tailMask(int b, float rad) =>
    float s  = math.sin(b * STEP)
    float sy = math.cos(b * STEP) >= 0 ? 1.0 : -1.0
    int   kc = math.floor(dialR * s)
    int   k0 = math.max(-dialR, kc - 1)
    int   k1 = math.min(dialR, kc + 2)
    float ro = math.max(rad, dialR + 0.5) + 0.25
    array<chart.point> pts = array.new<chart.point>()
    for k = k0 to k1
        pts.push(ptc(k, sy * (math.abs(k) < dialR ? circY(dialR, k) : sideH(dialR))))
    for k = k1 to k0
        pts.push(ptc(k, sy * math.sqrt(ro * ro - k * k)))
    pts

// Adds a closed shape to a shared list that is entered from the centre and left along the same path, so any
// number of them fill as one polyline.
packIn(array<chart.point> pack, array<chart.point> pts) =>
    if pack.size() == 0
        pack.push(pt(0, 0.0))
    for p in pts
        pack.push(p)
    pack.push(pts.get(0))
    pack.push(pt(0, 0.0))

var array<polyline> polys = array.new<polyline>()
var array<label>    lbls  = array.new<label>()
var array<box>      bxs   = array.new<box>()

poly(array<chart.point> pts, bool curved, bool closed, color strokeCol, color fillCol, string style, int width) =>
    polys.push(polyline.new(points = pts, curved = curved, closed = closed, xloc = xloc.bar_index, line_color = strokeCol, fill_color = fillCol, line_style = style, line_width = width))

fillPack(array<chart.point> pts, color col) =>
    if pts.size() > 0
        poly(pts, false, true, color.new(col, 100), col, line.style_solid, 1)

panelBox(int left, float top, int right, float bottom, color frameCol, int frameW, color fillCol) =>
    bxs.push(box.new(left = left, top = top, right = right, bottom = bottom, border_color = frameCol, border_width = frameW, border_style = line.style_solid, extend = extend.none, xloc = xloc.bar_index, bgcolor = fillCol))

// Text in a box of its own that sizes the text to fit, so it scales with the chart and cannot spill out of
// the space it was given.
txtBox(int left, float top, int right, float bottom, string txt, color txtCol, color fillCol, color frameCol, int frameW, string halign, bool bold, string fam) =>
    bxs.push(box.new(left = left, top = top, right = right, bottom = bottom, border_color = frameCol, border_width = frameW, border_style = line.style_solid, extend = extend.none, xloc = xloc.bar_index, bgcolor = fillCol, text = txt, text_size = size.auto, text_color = txtCol, text_halign = halign, text_valign = text.align_center, text_wrap = text.wrap_none, text_font_family = fam, text_formatting = bold ? text.format_bold : text.format_none))

// A space between the letters of a caption, for the design's widely spaced capitals.
track(string s) =>
    string o = ""
    int    n = str.length(s)
    if n > 0
        for i = 0 to n - 1
            o += (i > 0 ? " " : "") + str.substring(s, i, i + 1)
    o

// The price per bar to six significant figures, so the printed value can be typed back in on any instrument:
// a fixed four decimals prints 0 on a five-decimal forex pair.
ppbText(float p) =>
    int d = p > 0 ? math.max(0, 5 - math.floor(math.log10(p))) : 4
    str.tostring(p, d > 0 ? "0." + str.repeat("#", d) : "0")

//------------------------------------------------------------------------------
// Line tags, moved every bar
//------------------------------------------------------------------------------
var label tagFast = na
var label tagSlow = na

if barstate.islast and showTags
    // The tag gives the line's EMA length in chart bars - the number the line is actually built from. Both tags
    // are in the lines' own colour, lettered in whichever ink reads on it.
    if na(tagFast)
        tagFast := label.new(x = bar_index, y = close, text = "EMA " + str.tostring(lenFast), xloc = xloc.bar_index, yloc = yloc.price, color = lineUse, style = label.style_label_left, textcolor = textOn(lineUse), size = size.small)
        tagSlow := label.new(x = bar_index, y = close, text = "EMA " + str.tostring(lenSlow), xloc = xloc.bar_index, yloc = yloc.price, color = lineUse, style = label.style_label_left, textcolor = textOn(lineUse), size = size.small)
    // Each tag points at the end of its line. Where the two lines run within three ATRs of each other the tags
    // split: the upper one sits on top of its line and the lower one hangs below its own, so their bodies grow away
    // from each other and cannot cover one another at any zoom. Further apart, each is centred on its line. A line
    // that does not exist yet has no tag.
    bool split  = not na(emaFast) and not na(emaSlow) and math.abs(emaFast - emaSlow) < 3 * nz(atr, syminfo.mintick)
    bool fastUp = emaFast >= emaSlow
    tagFast.set_xy(bar_index + 2, emaFast)
    tagSlow.set_xy(bar_index + 2, emaSlow)
    tagFast.set_style(not split ? label.style_label_left : fastUp ? label.style_label_lower_left : label.style_label_upper_left)
    tagSlow.set_style(not split ? label.style_label_left : fastUp ? label.style_label_upper_left : label.style_label_lower_left)

//------------------------------------------------------------------------------
// Clock and panel, rebuilt once per closed bar
//------------------------------------------------------------------------------
// Drawn on the last historical bar and again each time a live bar closes. Drawings made on a closed bar are
// kept through the next bar's ticks, so the clock stays on screen without being rebuilt on every tick, which
// would hold back the script's live updates.
if (barstate.islastconfirmedhistory or (barstate.isrealtime and barstate.isconfirmed)) and (showClock or showPanel)
    for p in polys
        p.delete()
    polys.clear()
    for lb in lbls
        lb.delete()
    lbls.clear()
    for bx in bxs
        bx.delete()
    bxs.clear()

    color bg       = CHART_BG
    color ringCol  = mix(bg, SLATE, 0.25)
    color spokeCol = mix(bg, SLATE, 0.15)
    color hubEdge  = mix(bg, SLATE, 0.22)
    color edgeCol  = mix(bg, SLATE, 0.28)
    color bodyCol  = mix(bg, SLATE, 0.04)
    // The hand, the centre dot and the dashed line come from the chart's own colours rather than a fixed white,
    // so they stay visible on a light chart.
    color inkCol   = mix(bg, INK_ON_BG, 0.94)
    color dashCol  = mix(bg, INK_ON_BG, 0.48)

    float panTop = cy - dropU * rP
    float panBot = panTop - PANU * rP
    int   pw     = math.round(PANW * dialR)
    int   xl     = c0 - pw
    int   xr     = c0 + pw

    if noFlow
        // No usable volume anywhere in the loaded history, so there is nothing to draw and the chart says
        // why rather than showing a balanced market or taking the price lines down with a platform error.
        // Nothing is reserved on the price axis here: the message is one line, and holding the whole
        // figure's height open for it would squash the candles for no reason.
        txtBox(xl, cy + 0.05 * rP, xr, cy - 0.05 * rP, "Delta Pressure Clock: no usable volume on " + syminfo.ticker + " at " + (useBar ? "the chart timeframe" : tfName(timeframe.in_seconds(ltf))) + ", so delta cannot be estimated.", TXT_SOFT, NOFILL, NOFILL, 0, text.align_center, false, font.family_default)
    else
        // One invisible frame, measured from the drawing's real reach, so the auto-scaled price axis keeps
        // the whole picture in view. With the panel switched off it reaches only as far as the dial does,
        // instead of holding the panel's height open under an empty space.
        panelBox(c0 - far, cy + topU * rP, c0 + far, showPanel ? panBot - 0.02 * rP : cy - topU * rP, NOFILL, 0, NOFILL)

        // This 24 hours, the hour in progress included through its own bucket (closed bars only). A bucket
        // counts as live while its occurrence is one of the last 24 hour slots the chart has seen AND no
        // more than 25 hours of real time old. The slot count is what stops a weekend gap from reviving
        // Friday; the 25 hours is the backstop, one hour of slack so an ordinary 24-hour span is never
        // trimmed by rounding. On either clock-change day one hour number comes round twice inside the last
        // 24 slots and the array keeps one occurrence per number, so the dial carries 23 wedges that day.
        // An hour with no data in the window - whether it never traded or its last occurrence has aged
        // out - has no wedge, and neither has an hour that traded to an exactly zero net.
        array<float> today   = array.new<float>(24, 0.0)
        array<bool>  liveH   = array.new<bool>(24, false)
        array<float> typBase = array.new<float>(24, 0.0)
        for h = 0 to 23
            bool lv = slot.get(h) > 0 and slotNo - slot.get(h) < 24 and hStart - stamp.get(h) <= 90000000
            float v = lv ? bucket.get(h) : 0.0
            if h == curH
                v  := nowVal
                lv := haveNow
            if badType
                v  := 0.0
                lv := false
            today.set(h, v)
            liveH.set(h, lv)
            // The baseline each hour is measured against is the average as it stood BEFORE that hour's
            // latest occurrence was folded in, so the dashed line never contains the number it is judging.
            // The hour in progress has not been folded yet, so it reads the live average.
            float tv = h == curH ? nz(typ.get(h)) : nz(prevTyp.get(h))
            typBase.set(h, tv)

        // One shared scale for every wedge, the dashed ring and the banner's grade: wedgeRef, the 90th
        // percentile of the size of every finished hour the chart holds, measured as the chart loads. Reading it
        // here rather than building a scale from the hours on screen is what keeps a wedge still when a
        // neighbouring hour rolls. Until a first scale exists there is nothing to measure a wedge against, so
        // no wedge, no dashed ring and no grade is drawn; 1.0 only keeps the divisions defined.
        bool  haveRef = nz(wedgeRef) > 0
        float mx      = haveRef ? wedgeRef : 1.0

        bool  showNums = not badType
        color ownCol   = not haveNow or not showNums ? color.new(greyCol, 0) : nowVal > 0 ? color.new(greenCol, 0) : nowVal < 0 ? color.new(redCol, 0) : color.new(greyCol, 0)

        int nWin = 0
        for h = 0 to 23
            nWin += liveH.get(h) ? 1 : 0

        //----------------------------------------------------------------------
        // The clock
        //----------------------------------------------------------------------
        if showClock
            // The middle ring, under the wedges, drawn straight like the rim.
            poly(circlePts(math.round(R_MID * dialR)), false, true, ringCol, na, line.style_solid, 2)

            // Wedges, packed by colour. Each is the centre, then its curved end from one corner to the other,
            // so both sides are single straight lines from the centre. The core is the same shape, shorter,
            // laid on top.
            array<chart.point> gBase = array.new<chart.point>()
            array<chart.point> rBase = array.new<chart.point>()
            array<chart.point> nBase = array.new<chart.point>()
            array<chart.point> gCore = array.new<chart.point>()
            array<chart.point> rCore = array.new<chart.point>()
            // The bright tips' curves, one after another with each one's length, and how far out each hour line
            // has a wedge corner, for the gaps.
            array<chart.point> tipPts = array.new<chart.point>()
            array<int>         tipLen = array.new<int>()
            array<bool>        tUpS   = array.new<bool>()
            array<float> rayMax = array.new<float>(24, 0.0)
            for h = 0 to 23
                float v = today.get(h)
                if haveRef and liveH.get(h) and v != 0
                    float u   = math.min(1.0, math.abs(v) / mx)
                    bool  up  = v > 0
                    float ro0 = wedgeLen(u) * dialR
                    // The wedge's place mirrored into the first quadrant, and which way to mirror it back out.
                    int q  = h < 6 ? h : h < 12 ? 11 - h : h < 18 ? h - 12 : 23 - h
                    int sx = h < 12 ? 1 : -1
                    int sy = h < 6 or h >= 18 ? 1 : -1
                    // The tip's range: up to SNAP of the radius either side, never under R_PAD - 0.02 and never
                    // closer to the rim than RIM_GAP, the floor giving way to the ceiling on the smallest
                    // clocks.
                    float rTop = dialR - RIM_GAP
                    float rBot = (R_PAD - 0.02) * dialR
                    ArcPick tip = arcSearch(q, math.min(rTop, math.max(rBot, ro0 - SNAP * dialR)), math.min(rTop, ro0 + SNAP * dialR), ro0)
                    // Only where 3% still leaves a corner off its circle may the tip move further.
                    if tip.err > ERR_OK
                        ArcPick wide = arcSearch(q, math.min(rTop, math.max(rBot, ro0 - SNAP_MAX * dialR)), math.min(rTop, ro0 + SNAP_MAX * dialR), ro0)
                        if wide.score < tip.score - 1e-9
                            tip := wide
                    array<chart.point> tipArc = arcPts(q, tip.rho, sx, sy)
                    // An hour whose buying and selling all but cancelled is drawn in the flat colour rather than
                    // given a confident green or red it has not earned.
                    array<chart.point> body = math.abs(v) / mx < 0.02 ? nBase : up ? gBase : rBase
                    array<chart.point> shape = array.from(pt(0, 0.0))
                    shape.concat(tipArc)
                    packIn(body, shape)
                    if u >= U_CORE
                        // The core ends on a circle of its own about the same centre, so its end runs the same
                        // distance inside the wedge's all the way across.
                        float span = tip.rho - rh
                        float cTgt = rh + CORE_MID * span
                        ArcPick core = arcSearch(q, rh + 0.72 * span, rh + 0.86 * span, cTgt)
                        if core.err > ERR_OK
                            ArcPick core2 = arcSearch(q, rh + 0.64 * span, rh + 0.90 * span, cTgt)
                            if core2.score < core.score - 1e-9
                                core := core2
                        array<chart.point> coreShape = array.from(pt(0, 0.0))
                        coreShape.concat(arcPts(q, core.rho, sx, sy))
                        packIn(up ? gCore : rCore, coreShape)
                    if u >= U_TIP
                        tipPts.concat(tipArc)
                        tipLen.push(tipArc.size())
                        tUpS.push(up)
                    // Each corner's radius, on the hour line it really sits on: in the two quadrants mirrored in
                    // exactly one axis - 06 to 11 and 18 to 23 - the order reverses, so quadrant line q is the
                    // wedge's later hour line there. Mirroring in both axes turns the wedge through half a
                    // circle and keeps the order.
                    int   hb  = (h + 1) % 24
                    bool  qIsH = h < 6 or (h >= 12 and h < 18)
                    float rq  = cornerR(q, tip.rho)
                    float rq1 = cornerR(q + 1, tip.rho)
                    rayMax.set(h, math.max(rayMax.get(h), qIsH ? rq : rq1))
                    rayMax.set(hb, math.max(rayMax.get(hb), qIsH ? rq1 : rq))
            // The signature colours at full strength for the cores, and the outer parts the same hue nearer the
            // background.
            fillPack(nBase, mix(bg, color.new(greyCol, 0), BASE_MIX))
            fillPack(gBase, mix(bg, color.new(greenCol, 0), BASE_MIX))
            fillPack(rBase, mix(bg, color.new(redCol, 0), BASE_MIX))
            fillPack(gCore, color.new(greenCol, 0))
            fillPack(rCore, color.new(redCol, 0))

            // Over all the fills: the bright tip of the strongest wedges, one stroke of a fixed width along the
            // curved end, so it is the same thickness on every wedge and follows the same curve.
            int i0 = 0
            if tipLen.size() > 0
                for i = 0 to tipLen.size() - 1
                    color tipC = tUpS.get(i) ? greenCol : redCol
                    poly(tipPts.slice(i0, i0 + tipLen.get(i)).copy(), false, false, DARK_UI ? mix(tipC, color.white, 0.7) : lift(mix(tipC, INK_DARK, 0.35), 3.0), na, line.style_solid, TIP_PX)
                    i0 += tipLen.get(i)

            // Then the gaps. Down every hour line with a wedge on either side, a background band GAP_W columns
            // wide, whose two edges run exactly parallel to the line: every corner sits within EDGE of its
            // line, so the band covers every corner's miss and the edge of each wedge anyone sees is the band's
            // exact edge. Each band starts under the hub and ends past the further corner. An exactly vertical
            // line needs no band - its corners are on it - and its sides could not be drawn off it anyway, so
            // it has the stroke alone. The stroke, SEP_PX wide down each line, keeps every gap at least that
            // wide when the chart is zoomed out.
            array<chart.point> gaps = array.new<chart.point>()
            float gh = GAP_W / 2
            for b = 0 to 23
                float sb = math.sin(b * STEP)
                float cb = math.cos(b * STEP)
                if rayMax.get(b) > 0 and math.abs(sb) > 1e-9
                    float sOut = rayMax.get(b) + 0.3
                    // Edge e of the band meets column k at distance (k - off * cb) / sb along the line.
                    float offP = gh
                    float offM = -gh
                    int   kOP  = sb > 0 ? math.ceil(sOut * sb + offP * cb) : math.floor(sOut * sb + offP * cb)
                    int   kOM  = sb > 0 ? math.ceil(sOut * sb + offM * cb) : math.floor(sOut * sb + offM * cb)
                    float sIP  = -offP * cb / sb
                    float sIM  = -offM * cb / sb
                    float sOP  = (kOP - offP * cb) / sb
                    float sOM  = (kOM - offM * cb) / sb
                    packIn(gaps, array.from(ptc(0, sIP * cb - offP * sb), ptc(kOP, sOP * cb - offP * sb), ptc(kOM, sOM * cb - offM * sb), ptc(0, sIM * cb - offM * sb)))
            fillPack(gaps, bg)
            array<chart.point> sep = array.from(pt(0, 0.0))
            for b = 0 to 23
                if rayMax.get(b) > 0
                    sep.push(exactPt(b, rayMax.get(b) + 0.1))
                    sep.push(pt(0, 0.0))
            if sep.size() > 1
                poly(sep, false, false, bg, na, line.style_solid, SEP_PX)

            // Spokes on the exact hour lines, down the middle of every gap and out to the first point on the line
            // at or past the rim; where that is past it, a background patch cuts the spoke off at the rim. The
            // patches are packed into one fill.
            array<chart.point> sp    = array.from(pt(0, 0.0))
            array<chart.point> tails = array.new<chart.point>()
            for b = 0 to 23
                sp.push(exactPt(b, dialR - 0.02))
                sp.push(pt(0, 0.0))
                float sb  = math.abs(math.sin(b * STEP))
                float rad = sb < 1e-9 ? dialR : math.abs(exactK(b, dialR - 0.02)) / sb
                if rad > dialR + 0.02
                    packIn(tails, tailMask(b, rad))
            poly(sp, false, false, spokeCol, na, line.style_solid, 2)
            fillPack(tails, bg)

            // The rim, over the spokes' ends and the tips of any wedge that reaches it.
            poly(circlePts(dialR), false, true, ringCol, na, line.style_solid, 2)

            // Sessions: a grey dotted arc just outside the rim over each session's hours, and a small box naming
            // it, placed out from the middle of the arc far enough that its inner edge clears the hour numbers.
            if sessOn
                array<string> sNames = array.from("ASIA", "LONDON", "NEW YORK")
                array<string> sSpecs = array.from(sessAsia, sessLon, sessNy)
                int rS = math.round(R_SESS * dialR)
                for i = 0 to 2
                    string sSpec = sSpecs.get(i)
                    // Only the first time range of a session is drawn, and a spec too short to hold one is
                    // skipped rather than read past its end, which would stop the whole script.
                    if str.length(sSpec) >= 9
                        float  sStart = nz(str.tonumber(str.substring(sSpec, 0, 2))) + nz(str.tonumber(str.substring(sSpec, 2, 4))) / 60.0
                        float  sEnd = nz(str.tonumber(str.substring(sSpec, 5, 7))) + nz(str.tonumber(str.substring(sSpec, 7, 9))) / 60.0
                        sEnd := sEnd <= sStart ? sEnd + 24 : sEnd
                        // A slice of a circle drawn exactly as the rim is, a whole number of columns out.
                        array<chart.point> arcP = ringArc(rS, sStart * 15, (sEnd - sStart) * 15)
                        if arcP.size() >= 2
                            poly(arcP, false, false, dashCol, na, line.style_dotted, 2)
                        string nm  = sNames.get(i)
                        float  am  = (sStart + sEnd) / 2 * STEP
                        float  hw  = 0.0368 + 0.0163 * str.length(nm)
                        float  rc  = R_SLAB + hw * math.abs(math.sin(am)) + SLAB_H * math.abs(math.cos(am))
                        int    xc  = c0 + symRound(dialR * rc * math.sin(am))
                        float  yc  = cy + rP * rc * math.cos(am)
                        int    hwc = math.max(2, math.round(hw * dialR))
                        txtBox(xc - hwc, yc + SLAB_H * rP, xc + hwc, yc - SLAB_H * rP, nm, TXT_SOFT, bodyCol, edgeCol, 1, text.align_center, true, font.family_default)

            // Each hour's usual size, as one dashed line round the dial with a corner at the middle of every
            // hour that has a history.
            array<chart.point> dash = array.new<chart.point>()
            if showNums and haveRef
                for h = 0 to 23
                    float tv = typBase.get(h)
                    if tv != 0
                        float rt = wedgeLen(math.min(1.0, math.abs(tv) / mx))
                        float am = (h + 0.5) * STEP
                        dash.push(chart.point.from_index(c0 + symRound(dialR * rt * math.sin(am)), cy + rP * rt * math.cos(am)))
            if dash.size() >= 3
                poly(dash, false, true, dashCol, na, line.style_dashed, 2)

            // Hub over the wedge roots, drawn straight like the other circles: TradingView's curve rounding pinched
            // the short upright run at its sides into a visible nick.
            poly(circlePts(rh), false, true, hubEdge, bg, line.style_solid, 1)

            // The hand, from the centre to just past the rim at the time of day the bar opened, and a dot on
            // the centre. It carries the minutes as well as the hour, so it sweeps its hour at a quarter of a
            // degree a minute instead of sitting at the hour's middle and jumping a whole sector at the roll.
            // With no bars at all in the window there is no hour to point at, so neither is drawn.
            if nWin > 0
                float aNow = (curH + minute(time, tz) / 60.0) * STEP
                chart.point hand0 = chart.point.from_index(c0, cy)
                chart.point hand1 = chart.point.from_index(c0 + symRound(dialR * R_HAND * math.sin(aNow)), cy + rP * R_HAND * math.cos(aNow))
                poly(array.from(hand0, hand1), false, false, inkCol, na, line.style_solid, 3)
                lbls.push(label.new(x = c0, y = cy, text = "", xloc = xloc.bar_index, yloc = yloc.price, color = inkCol, style = label.style_circle, size = size.tiny))

            // The hour numbers in a ring outside the rim.
            int numW = math.max(1, math.round(0.08 * dialR))
            for hn = 0 to 21 by 3
                float an = hn * STEP
                int   xn = c0 + symRound(dialR * R_NUM * math.sin(an))
                float yn = cy + rP * R_NUM * math.cos(an)
                txtBox(xn - numW, yn + 0.042 * rP, xn + numW, yn - 0.042 * rP, str.tostring(hn, "00"), TXT_NUM, NOFILL, NOFILL, 0, text.align_center, false, font.family_monospace)

            if nWin == 0
                int msgW = math.round(0.6 * dialR)
                // On a chart whose candles are manufactured there are plenty of bars - the readings were
                // dropped on purpose - so the dial must not blame the data and contradict the banner.
                txtBox(c0 - msgW, cy + 0.05 * rP, c0 + msgW, cy - 0.05 * rP, showNums ? "no bars in the last 24 hours" : "readings are blanked on this chart type", TXT_SOFT, NOFILL, NOFILL, 0, text.align_center, false, font.family_default)

        //----------------------------------------------------------------------
        // The panel
        //----------------------------------------------------------------------
        if showPanel
            // Every piece of text sits in a box of its own that sizes it to fit, so nothing in the panel can
            // run out of its space at any zoom or any radius.
            int pin = math.max(1, math.round(0.076 * dialR))
            int sg  = math.max(1, math.round(0.044 * dialR))
            int iL  = xl + pin
            int iR  = xr - pin

            // Frame, and the banner: the hour in progress, read from this occurrence and not from the slot, so
            // a brand new hour cannot print yesterday's total, filled in the colour of whoever owns it.
            panelBox(xl, panTop, xr, panBot, edgeCol, 1, bodyCol)
            panelBox(xl, panTop, xr, panTop - 0.204 * rP, ownCol, 1, ownCol)
            // The hour in progress graded on the wedges' own scale: strong from the strength that gives a wedge its
            // bright tip, medium from the strength that gives it a full-colour core, mild below that.
            float  uNow   = math.min(1.0, math.abs(nowVal) / mx)
            string grade  = not haveRef ? "" : uNow >= U_TIP ? "Strong " : uNow >= U_CORE ? "Medium " : "Mild "
            string banTxt = not showNums ? "Not Valid On This Chart Type" : not haveNow ? "Waiting For Data" : nowVal > 0 ? grade + "Buying Pressure" : nowVal < 0 ? grade + "Selling Pressure" : "Balanced"
            txtBox(iL, panTop - 0.050 * rP, iR, panTop - 0.155 * rP, banTxt, textOn(ownCol), NOFILL, NOFILL, 0, text.align_left, true, font.family_default)

            // Which side has usually owned this hour - said only once at least three finished sessions stand
            // behind it.
            int nSeen = math.min(seen.get(curH), typDays)
            int nUpS  = 0
            if nSeen > 0
                int p0 = ringPos.get(curH)
                for j = 1 to nSeen
                    nUpS += upRing.get(curH * RING + (p0 - j + RING) % RING) > 0 ? 1 : 0
            string habit = not showNums ? "n/a" : nSeen < 3 ? "no history yet" : 2 * nUpS > nSeen ? "buyers (typical)" : 2 * nUpS < nSeen ? "sellers (typical)" : "mixed (typical)"
            txtBox(iL, panTop - 0.2685 * rP, c0 - math.round(0.05 * dialR), panTop - 0.3635 * rP, "Usually Owned By:", TXT_SOFT, NOFILL, NOFILL, 0, text.align_left, false, font.family_default)
            txtBox(c0 - math.round(0.03 * dialR), panTop - 0.2685 * rP, iR, panTop - 0.3635 * rP, habit, TXT_MAIN, NOFILL, NOFILL, 0, text.align_right, false, font.family_monospace)

            // The three windows, each filled solid in the colour of its side, lettered in whichever ink reads
            // on it.
            array<float>  tfU = array.from(uFast, uMid, uSlow)
            array<string> tfN = array.from(nameFast, nameMid, nameSlow)
            float tileW = (iR - iL - 2 * sg) / 3.0
            for i = 0 to 2
                float u = tfU.get(i)
                int   s = na(u) ? 2 : math.abs(u) < 0.05 ? 0 : u > 0 ? 1 : -1
                // Edges as offsets from the centre, rounded half away from zero, so the tiles are mirror-symmetric.
                float o0 = iL - c0 + i * (tileW + sg)
                int   b0 = c0 + symRound(o0)
                int   b1 = c0 + symRound(o0 + tileW)
                color tileCol = not showNums or s == 0 or s == 2 ? color.new(greyCol, 0) : s > 0 ? color.new(greenCol, 0) : color.new(redCol, 0)
                color tileInk = textOn(tileCol)
                string word = not showNums ? "N/A" : s == 2 ? "NO DATA" : s == 0 ? "NEUTRAL" : s > 0 ? "BUYING" : "SELLING"
                panelBox(b0, panTop - 0.408 * rP, b1, panTop - 0.658 * rP, tileCol, 1, tileCol)
                txtBox(b0 + 1, panTop - 0.449 * rP, b1 - 1, panTop - 0.535 * rP, tfN.get(i), tileInk, NOFILL, NOFILL, 0, text.align_center, false, font.family_monospace)
                txtBox(b0 + 1, panTop - 0.540 * rP, b1 - 1, panTop - 0.626 * rP, track(word), tileInk, NOFILL, NOFILL, 0, text.align_center, true, font.family_default)

            // A key for the clock, then what is running, so a screenshot can always be read back.
            panelBox(xl, panTop - 0.717 * rP, xr, panTop - 0.717 * rP, edgeCol, 1, NOFILL)
            string keyTxt = "Wedges = estimated delta, last 24h, hours in " + tzLab + " · dashed = typical" + (colourBars ? " · candles = side of EMA " + str.tostring(candleLine == "Fast line" ? lenFast : lenSlow) : "")
            string verTxt = "· Delta Pressure Clock · src " + (useBar ? "chart bars" : tfName(timeframe.in_seconds(ltf))) + " · EMA " + str.tostring(lenFast) + "/" + str.tostring(lenSlow) + " · ppb " + ppbText(ppbUse) + " · as of " + str.format_time(time, "MM-dd HH:mm", tz) + " " + tzLab + " (bar open)"
            txtBox(iL, panTop - 0.767 * rP, iR, panTop - 0.839 * rP, keyTxt, TXT_DIM, NOFILL, NOFILL, 0, text.align_left, false, font.family_default)
            txtBox(iL, panTop - 0.859 * rP, iR, panTop - 0.931 * rP, verTxt, TXT_DIM, NOFILL, NOFILL, 0, text.align_left, false, font.family_default)

            // A row each. Run together on one row, the two notes are about two hundred characters in the
            // width the panel gives one line, which is half the character width of every other row in it.
            float nRow = 0.951
            color noteCol = lift(color.new(greyCol, 0), 4.8)
            if badType
                txtBox(iL, panTop - nRow * rP, iR, panTop - (nRow + 0.072) * rP, "Not a standard candle chart: the opens and closes are not real prices, so every reading is blanked.", noteCol, NOFILL, NOFILL, 0, text.align_left, false, font.family_default)
                nRow := nRow + ROW_NOTE
            if lineCut
                // The same flag covers a tile window that hit the ceiling and a line that did, so the
                // wording has to cover both rather than naming only the lines.
                txtBox(iL, panTop - nRow * rP, iR, panTop - (nRow + 0.072) * rP, "A window or a line needs more history than TradingView holds; the names and lengths above are the ones actually used.", noteCol, NOFILL, NOFILL, 0, text.align_left, false, font.family_default)